Skip to content

Python: resolve forward references nested in generic aliases when building schemas - #14310

Open
om singhal (Om-singhaI) wants to merge 12 commits into
microsoft:mainfrom
Om-singhaI:fix/schema-builder-nested-forward-refs
Open

om singhal (Om-singhaI) wants to merge 12 commits into
microsoft:mainfrom
Om-singhaI:fix/schema-builder-nested-forward-refs

Conversation

@Om-singhaI

@Om-singhaI om singhal (Om-singhaI) commented Aug 22, 2026 •

Copy link
Copy Markdown

Motivation and Context

Fixes #14239

A string forward reference inside a generic alias, such as list["Inner"], dict[str, "Inner"] or list[list["Inner"] | None], is never resolved to the class it names. On a kernel function the same goes for Optional["Inner"], Union["Inner", None] and Annotated["Inner", ...], which unwrap to a bare ForwardRef. The schema built for it is the bare {"type": "object"} placeholder with no properties and no required list. That schema is what the model receives as a function calling parameter definition, so a plugin written this way hands the model an untyped blob for that argument without any error or warning. A top level "Inner" already works, which makes the gap easy to miss.

Two entry points are affected, and they differ by Python version:

entry point 3.10 3.11 / 3.12 / 3.13
@kernel_function parameter items: list["Inner"] broken broken
@kernel_function parameter x: Optional["Inner"] or Annotated["Inner", ...] broken broken
KernelBaseModel field items: list["Inner"] broken works

Why the decorator path is broken on every version. kernel_function_decorator._process_signature reads annotations from inspect.signature(func, eval_str=True). eval_str evaluates an annotation that is itself a string, but not a string nested inside an alias that already exists as an object. type_object therefore reaches KernelJsonSchemaBuilder.build as list['Inner'] with the bare str still in __args__. handle_complex_type then calls build("Inner"), which takes the isinstance(parameter_type, str) branch and falls through build_from_type_name to the placeholder. In the bare ForwardRef cases invoking the function also fails, with Parameter x is expected to be parsed to ForwardRef('Inner') but is not.

class P:
    @kernel_function(name="fwd")
    def fwd(self, items: list["Inner"]) -> str: ...

KernelFunction.from_method(P().fwd).metadata.parameters[0].schema_data
# before: {"type": "array", "items": {"type": "object"}}
# after:  {"type": "array", "items": {"type": "object", "properties": {"value": ..., "label": ...}, "required": [...]}}

Why the model field path is 3.10 only. build_model_schema goes through get_type_hints, which on 3.10 evaluates ForwardRef objects nested inside an alias but not bare strings. list.__class_getitem__ stores "Inner" verbatim with no ForwardRef wrapper:

# Python 3.10
get_type_hints(HolderFwd)["items"]   # list['Inner']   the str survives
# Python 3.11 and later
get_type_hints(HolderFwd)["items"]   # list[Inner]     resolved

Python 3.11 changed get_type_hints to wrap those strings itself (CPython issue 85542, listed in the 3.11 What's New under typing). 3.10 is still inside requires-python = ">=3.10" and is a first class leg of the python-unit-tests.yml matrix, so the placeholder is what 3.10 users ship today.

Description

  • KernelJsonSchemaBuilder.resolve_forward_refs(annotation, globalns), new and public. Resolves str and ForwardRef references against a namespace, bare or at any depth. PEP 604 unions are rebuilt with reduce(or_, args), as typing._eval_type does, because types.UnionType is not subscriptable. Callable parameter lists, ClassVar and Final are handled too. Literal members and Annotated metadata are left alone, since those strings are values rather than names.
  • What stays a placeholder. A name that cannot be resolved is left as it is, keeping the previous placeholder rather than raising. The same goes, with a warning, for a reference to a class whose own hints do not evaluate, which would otherwise raise at registration. A rebuild that fails logs a warning and keeps the alias.
  • build_model_schema applies it against the model's module globals, which are already fetched a few lines above for the get_type_hints call. On 3.11 and later model field hints arrive resolved and the resolver returns them unchanged. It also returns the placeholder for a model already being built further up the call stack; see the next section.
  • _process_signature gains an optional globalns. The decorator passes func.__globals__ and each parameter's type_object is resolved before it is stored. Callers that pass nothing get the previous behaviour; the existing _process_signature(func_sig) tests are untouched.

Return type annotations are deliberately out of scope. -> list[Inner] already collapses to a bare list type_object today regardless of forward references, which is a separate limitation.

Relationship to #14198. This overlaps it in one place. Once references resolve, a self referencing model such as a kernel function parameter typed list["Node"] recurses where main stopped at a placeholder, so this PR needs a guard in build_model_schema. #14198 fixes the same RecursionError in the same function with $ref and $defs, which keeps the shape of the cycle where this guard cuts it off. Whichever lands second has to be reconciled, and the $ref design can replace the guard outright. Everything else here fires on a plain list["Inner"] with no recursion at all. As the issue notes, it is also why two of the new tests in #14198 fail on 3.10: TreeNode and Author/Book reach their cycles through list[...], so the string never resolves to a class and the cycle detection never engages.

Known limits, all the same as main. These stay a placeholder: a string expression such as list["list[Inner]"], an alias whose value holds a string, a class defined after the decorated function, and a function wrapped with functools.wraps from another module, because the namespace comes from the wrapper. list[list[Inner] | None] gets a correct schema but still cannot be invoked with a value, with or without forward references, because _parse_parameter calls the union.

Known differences from main. list["Good"] raises at registration where main gave the placeholder if the builder cannot build Good, for example when Good refers directly to a class whose hints do not evaluate. list[Good] raises the same way on main. typing.Sequence["Inner"], typing.Mapping[str, "Inner"] and similar aliases rebuild through collections.abc, so their placeholder becomes {"type": "object", "properties": {}}.

Testing plan

$ pytest tests/unit/schema tests/unit/functions/test_kernel_function_decorators.py

# Python 3.10.6
162 passed                                 (main: 92 passed)

# Python 3.13.15
161 passed, 1 skipped                      (main: 92 passed)

# Python 3.14.3
1 failed, 100 passed, 1 skipped, 1 error   (main: 1 failed, 57 passed, 1 error)

tests/unit/functions as a whole gives 232 passed on 3.10 and on 3.13. 3.11 and 3.12 were not run locally.

New tests against the source on main:

tests fail on main on
test_build_model_schema_resolves_forward_ref_in_*, test_build_model_schema_resolves_nested_forward_ref, test_build_model_schema_expands_a_repeated_model_every_time 3.10
test_kernel_function_resolves_forward_ref_in_list_parameter, test_process_signature_resolves_forward_refs_with_globalns 3.10, 3.13
test_kernel_function_schema_resolves_forward_ref_in_* (nullable list, nested nullable list, dict nested in a union) 3.10, 3.13
test_kernel_function_schema_resolves_bare_forward_ref, test_kernel_function_invocation_parses_bare_forward_ref 3.10, 3.13
test_resolve_forward_refs_* (unions, Callable, special forms, bare references, warnings) all, the method is new
test_build_model_schema_stops_at_a_mutual_reference 3.10, 3.13, 3.14
test_build_model_schema_stops_at_a_self_reference 3.13, 3.14
test_kernel_function_schema_stops_at_* 3.10, 3.13
test_kernel_function_schema_keeps_placeholder_when_class_hints_do_not_evaluate none, guards the placeholder main gives
test_build_model_schema_resolves_forward_ref_inside_optional none, pins that nested Optional keeps working
test_build_model_schema_unresolvable_forward_ref_falls_back none, 3.10 only, skipif >= 3.11 where get_type_hints raises NameError before the builder sees the name

Removing any one of these parts of the builder fails tests on 3.10 and on 3.13: the union join, the single type forms, the Callable list, the rebuild warning, the recursion guard, the bare reference, the hints check and its warning.

One thing worth flagging: test_kernel_function_decorators.py does not collect on Python 3.14 on pristine main either (TypeError: 'member_descriptor' object is not iterable in _parse_parameter for any union annotation), and test_build_model_schema_class_var_via_instance fails there too. Those are the 1 error and 1 failure above. They are outside the CI matrix, so on 3.14 the resolver is covered through test_schema_builder.py only.

Lint and types: ruff check and ruff format --check pass on all four files with 0.9.6, the pre commit pin. mypy --config-file mypy.ini reports no issues in either changed source file.

Contribution Checklist

…lding schemas

A string forward reference inside a generic alias, such as list["Inner"] or
dict[str, "Inner"], was never resolved to the class it names. The schema built
for it was the bare {"type": "object"} placeholder with no properties and no
required list. That schema is what the model receives as a function calling
parameter definition, so a plugin written this way handed the model an untyped
blob for that argument without any error or warning. Optional["Inner"] and a
top level "Inner" already worked, which made the gap easy to miss.

Two entry points were affected.

Parameters of a kernel_function, on every supported Python version. The
decorator reads annotations through inspect.signature with eval_str=True, which
evaluates an annotation that is itself a string but leaves a string nested
inside an alias untouched. type_object therefore reached
KernelJsonSchemaBuilder.build as list['Inner'] with the bare string still in
__args__.

Fields of a KernelBaseModel, on Python 3.10 only. build_model_schema relies on
get_type_hints, which on 3.10 evaluates ForwardRef objects nested in an alias
but not bare strings, and list.__class_getitem__ stores "Inner" verbatim with
no ForwardRef wrapper. Python 3.11 changed get_type_hints to wrap those strings
itself (CPython issue 85542), so this path already works on 3.11 and later.
3.10 remains in the supported range and in the CI matrix.

KernelJsonSchemaBuilder.resolve_forward_refs resolves string and ForwardRef
arguments against a namespace and walks nested aliases. Literal members and
Annotated metadata are left alone because those strings are values rather than
names. build_model_schema applies it against the model's module globals, which
were already fetched for the get_type_hints call, and the decorator applies it
to each parameter's type_object against the function's own __globals__, the
same namespace eval_str already uses. A name that cannot be resolved is left as
it is, so the previous placeholder behaviour is kept instead of raising. On
3.11 and later the model field path receives resolved hints and the resolver
returns them unchanged.

Fixes microsoft#14239
@Om-singhaI
om singhal (Om-singhaI) requested a review from a team as a code owner August 22, 2026 03:28
Copilot AI lite review requested due to automatic review settings August 22, 2026 03:28

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a schema-generation gap in the Python SDK where string forward references nested inside generic aliases (e.g., list["Inner"], dict[str, "Inner"]) were not being resolved, causing JSON schema placeholders ({"type":"object"}) to be emitted for nested model types. The change introduces a forward-ref resolver used both in KernelJsonSchemaBuilder.build_model_schema and in the @kernel_function decorator signature parsing, plus unit tests covering the previously broken cases (notably on Python 3.10).

Changes:

  • Add KernelJsonSchemaBuilder.resolve_forward_refs(...) and apply it during model schema building.
  • Extend _process_signature(...) to accept a globalns and resolve nested forward refs for decorator-parsed parameter types.
  • Add unit tests for list/dict/nested/optional forward refs and decorator signature parsing behavior.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
python/semantic_kernel/schema/kernel_json_schema_builder.py Adds forward-ref resolution for nested generic aliases during schema building.
python/semantic_kernel/functions/kernel_function_decorator.py Passes globals into signature processing and resolves nested forward refs in parameter annotations.
python/tests/unit/schema/test_schema_builder.py Adds regression tests for forward refs in list/dict/nested/optional model fields (esp. Py3.10).
python/tests/unit/functions/test_kernel_function_decorators.py Adds regression tests for decorator parsing of nested forward refs (including Annotated and `
Suppressed comments (1)

python/semantic_kernel/functions/kernel_function_decorator.py:139

  • if globalns: will skip resolution if an explicit empty dict is passed. Since the function signature treats globalns=None as the opt-out, this should check is not None instead so callers can intentionally pass an empty namespace and still get deterministic behavior.
        if globalns:
            underlying_type = KernelJsonSchemaBuilder.resolve_forward_refs(underlying_type, globalns)

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

…ence resolution

_process_signature used a truthiness check on globalns, so a caller passing an
empty dict was silently skipped even though None is the documented way to opt
out. Checking for None keeps the two cases distinct and the behaviour
predictable. Suggested in review.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAF Automated Review — Iteration 1

Result: Findings reported
Scope: full PR (1 commit(s)): 943e551d8c11
Model: claude-opus-4.8

Overview

The PR delivers its stated fix: string forward references nested inside PEP 585 generic aliases
(list["Inner"], dict[str, "Inner"], and nested/Optional forms) now resolve to their target
types instead of falling through to the untyped {"type": "object"} placeholder, both in the
``@kernel_function decorator path (all Python versions) and in `build_model_schema` (Python 3.10).
The resolution is a name lookup (`dict.get`), not an `eval`, and is well guarded for `Literal`,
`Annotated`, unresolvable names, and non-rebuildable aliases, with matching tests. The one residual
risk is that resolution substitutes whatever a colliding global names — including modules,
functions, and plain values — without confirming it is a type, which converts the previous graceful
placeholder into a crash (or a silently wrong schema) at function registration. This behavior is
Python-version-sensitive by design (a no-op on 3.11+ for the model-field path, active everywhere for
the decorator path).

Reviewed the supplied pull-request change set across correctness, security/reliability, architecture, and failure behavior.
1 verified finding remained after source verification (1 medium) across 1 file. Details are attached to the affected lines below.

Affected areas: python/semantic_kernel/schema/kernel_json_schema_builder.py

… a type

A nested reference whose name happened to match a module, a function or a
plain value in the namespace was rebuilt around that object. A module then
reached build_model_schema and failed on __module__ at decoration time, and a
function or value produced a misleading schema. Before this change the same
annotation degraded to the placeholder, so this was a regression in failure
behaviour. The lookup now substitutes only when the value is a class or a
typing construct and otherwise keeps the original reference, which restores
the placeholder fallback. Raised in review.
@Om-singhaI

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

@Om-singhaI

Copy link
Copy Markdown
Author

Eduard van Valkenburg (@eavanvalkenburg) thanks for merging main in. Every job on that run failed with "API rate limit exceeded for installation" before any tests ran. Could you rerun the checks when you get a chance?

@semantic-kernel-automation semantic-kernel-automation Bot added the python Pull requests for the Python Semantic Kernel label Oct 26, 2026
return annotation

try:
return origin[resolved_args]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PEP 604 unions cannot be rebuilt with origin[resolved_args]: their origin is types.UnionType, which is not subscriptable. The catch silently restores the unresolved alias. For example, resolve_forward_refs(list[list["Inner"] | None], {"Inner": Inner}) still returns the original string-containing annotation. I also reproduced this through a decorated function: its nested nullable-list item schema is only {"type":"object"}, without Inner properties. Please rebuild PEP 604 unions explicitly and add a nested nullable-generic regression, so the forward-reference fix reaches this supported annotation shape too.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4a3bebd. PEP 604 unions are now rebuilt with reduce(or_, args), so your example resolves and the decorated item schema has the Inner properties. Optional["Inner"] on a kernel function had the same gap and is fixed too. Self referencing models now stop at the placeholder instead of recursing, which overlaps #14198. Targeted tests: 162 passed on 3.10, 161 passed and 1 skipped on 3.13.

types.UnionType is not subscriptable, so origin[args] raised and the caught
TypeError restored the unresolved alias. list[list["Inner"] | None] kept its
string and the item schema stayed a bare object. Join these unions with | as
get_type_hints does. On Python 3.14 types.UnionType is typing.Union, and the
same path handles both spellings there.

The same rebuild dropped ClassVar and Final, which reject a one element tuple,
and the walk skipped the nested parameter list of Callable. Handle both. A
rebuild that still fails now logs a warning instead of passing silently.

Optional["Inner"], Union["Inner", None] and Annotated["Inner", ...] on a kernel
function unwrap to a bare ForwardRef, which has no origin and was returned as
it was: the schema stayed a bare object and invoking the function failed.
Resolve a bare reference too.

Resolving references also means a model that refers back to itself recurses
without end where main stops at a placeholder, for example a kernel function
parameter typed list["Node"]. build_model_schema now returns that placeholder
for a model already being built further up the call stack. A class whose own
hints do not evaluate would raise at registration in the same way, so a
reference to one stays unresolved with a warning and keeps the placeholder.

This branch was successfully deployed

1 active deployment
github-app-auth — 4a3bebd3 Deployed Nov 3, 2026 by Om-singhaI via add_label #29563
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python Pull requests for the Python Semantic Kernel

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python: KernelJsonSchemaBuilder ignores string forward references inside list[...]/dict[...], emitting a bare {"type": "object"}

3 participants